import {
Button, CaptureVideoPreviewView, HStack,
Image, Navigation, NavigationStack, Script, Spacer, Text, Toggle,
Toolbar, ToolbarItem, useEffect, useMemo, useObservable, VStack
} from "scripting"
type CaptureLog = {
ms: number
size: string
isDeferredProxy: boolean
index: number
}
function View() {
const dismiss = Navigation.useDismiss()
const isRunning = useObservable(false)
const lastImage = useObservable<UIImage | null>(null)
const log = useObservable<CaptureLog[]>([])
const captureCount = useObservable(0)
// 4 个响应性开关 + 一个能 / 不能控制的标记
const supports = useObservable({
zsl: false, resp: false, fast: false, defer: false,
})
// 用户当前的开关状态(不一定实际生效, 由 *Supported 决定)
const zsl = useObservable(false)
const resp = useObservable(false)
const fast = useObservable(false)
const defr = useObservable(false)
const { session, camera, photoOutput } = useMemo(() => {
const camera = AVCaptureDevice.default("video")!
const session = new AVCaptureSession()
const input = new AVCaptureDeviceInput(camera)
const photoOutput = new AVCapturePhotoOutput()
photoOutput.maxPhotoQualityPrioritization = "quality"
session.configure(() => {
session.sessionPreset = "photo"
if (session.canAddInput(input)) session.addInput(input)
if (session.canAddOutput(photoOutput)) session.addOutput(photoOutput)
})
return { session, camera, photoOutput }
}, [])
// 把 supported + enabled 状态从 native 重新拉一遍, 并 log 一行方便观察状态机。
// *Supported 不是常量: fastSupp 依赖 respEn=true, 所以切 resp 之后必须重读。
function syncFromNative(label: string) {
const snapshot = {
zslSupp: photoOutput.isZeroShutterLagSupported,
respSupp: photoOutput.isResponsiveCaptureSupported,
fastSupp: photoOutput.isFastCapturePrioritizationSupported,
deferSupp: photoOutput.isAutoDeferredPhotoDeliverySupported,
zslEn: photoOutput.isZeroShutterLagEnabled,
respEn: photoOutput.isResponsiveCaptureEnabled,
fastEn: photoOutput.isFastCapturePrioritizationEnabled,
deferEn: photoOutput.isAutoDeferredPhotoDeliveryEnabled,
}
supports.setValue({
zsl: snapshot.zslSupp,
resp: snapshot.respSupp,
fast: snapshot.fastSupp,
defer: snapshot.deferSupp,
})
zsl.setValue(snapshot.zslEn)
resp.setValue(snapshot.respEn)
fast.setValue(snapshot.fastEn)
defr.setValue(snapshot.deferEn)
console.log(`[${label}]`, snapshot)
}
useEffect(() => {
async function start() {
try {
await session.startRunning()
isRunning.setValue(true)
// 启动后 *Supported 才反映真实硬件能力 + active format
syncFromNative("startRunning")
} catch (e) {
await Dialog.alert({ message: `Failed to start: ${String(e)}` })
dismiss()
}
}
start()
return () => {
session.stopRunning().finally(() => session.dispose())
}
}, [])
function flipZSL(v: boolean) {
photoOutput.isZeroShutterLagEnabled = v
syncFromNative(`zsl=${v}`)
}
function flipResp(v: boolean) {
// bridge 在 native 兜底:开 resp 不会自动开 fast(需要单独开);
// 关 resp 时会先关 fast 保持状态一致。
photoOutput.isResponsiveCaptureEnabled = v
syncFromNative(`resp=${v}`)
}
function flipFast(v: boolean) {
// 开 fast 时 bridge 自动把 resp 也开起来(fastSupp 依赖 respEn=true);
// 关 fast 单独关。
photoOutput.isFastCapturePrioritizationEnabled = v
syncFromNative(`fast=${v}`)
}
function flipDefer(v: boolean) {
photoOutput.isAutoDeferredPhotoDeliveryEnabled = v
syncFromNative(`defer=${v}`)
}
async function takeShot() {
const start = Date.now()
try {
const result = await photoOutput.capturePhoto({ codec: "hevc" })
const ms = Date.now() - start
const idx = captureCount.value + 1
captureCount.setValue(idx)
lastImage.setValue(result.image)
log.setValue([
{
index: idx,
ms,
size: `${result.image.width}×${result.image.height}`,
isDeferredProxy: result.isDeferredProxy,
},
...log.value,
].slice(0, 8))
} catch (e) {
await Dialog.alert({ message: `capturePhoto failed: ${String(e)}` })
}
}
/** 连拍 5 张, 测响应性体感 */
async function burst5() {
for (let i = 0; i < 5; i++) {
// 不 await 确保连发(响应性开启时这是关键体感)
takeShot()
}
}
return (
<NavigationStack>
<VStack
navigationTitle="Responsive capture"
toolbar={
<Toolbar>
<ToolbarItem placement="topBarTrailing">
<Button title="Done" systemImage="xmark" action={dismiss} />
</ToolbarItem>
</Toolbar>
}
>
<CaptureVideoPreviewView
session={session}
videoDevice={camera}
videoGravity="resizeAspectFill"
frame={{ height: 280 }}
cornerRadius={12}
masksToBounds
/>
<VStack alignment="leading" spacing={4} padding={8}>
<Text font="caption">Capability (read after startRunning)</Text>
<Text font="footnote">
zsl: {String(supports.value.zsl)} · resp: {String(supports.value.resp)} · fast: {String(supports.value.fast)} · defer: {String(supports.value.defer)}
</Text>
</VStack>
<VStack alignment="leading" spacing={6} padding={8}>
<Toggle
title="Zero Shutter Lag"
value={zsl.value}
onChanged={flipZSL}
disabled={!supports.value.zsl}
/>
<Toggle
title="Responsive Capture"
value={resp.value}
onChanged={flipResp}
disabled={!supports.value.resp}
/>
<Toggle
title="Fast Capture Prioritization"
value={fast.value}
onChanged={flipFast}
disabled={!supports.value.fast}
/>
<Toggle
title="Auto Deferred Photo Delivery"
value={defr.value}
onChanged={flipDefer}
disabled={!supports.value.defer}
/>
</VStack>
<HStack padding={8} spacing={12}>
<Button title="Single shot" action={takeShot} />
<Button title="Burst × 5" action={burst5} />
<Spacer />
<Text font="footnote" foregroundStyle="secondaryLabel">
shots: {captureCount.value}
</Text>
</HStack>
{lastImage.value ? (
<Image
image={lastImage.value}
resizable
scaleToFit
frame={{ height: 120 }}
/>
) : null}
<VStack alignment="leading" spacing={2} padding={8}>
<Text font="caption">Recent captures (newest first)</Text>
{log.value.map(entry => (
<Text key={entry.index} font="footnote">
#{entry.index} · {entry.ms}ms · {entry.size}{entry.isDeferredProxy ? " · proxy" : ""}
</Text>
))}
</VStack>
</VStack>
</NavigationStack>
)
}
async function run() {
await Navigation.present({ element: <View /> })
Script.exit()
}
run()